在前一篇中,我們讓使用者能夠新增飲食紀錄,但目前資料仍是存在記憶體中,一旦重新啟動 App,資料就會消失。
今天,我們要讓使用者能夠查看飲食紀錄清單頁,並且透過 SwiftData 將資料永久保存!
我們先將原本的 MealRecord
改寫為 SwiftData 的資料實體。
import Foundation
import SwiftData
@Model
class MealRecord {
var id: UUID
var name: String
var calories: Int
var category: MealCategory
var date: Date
init(
id: UUID = UUID(),
name: String,
calories: Int,
category: MealCategory,
date: Date = .now
) {
self.id = id
self.name = name
self.calories = calories
self.category = category
self.date = date
}
}
並確保餐點分類 MealCategory
能被編碼與儲存:
enum MealCategory: String, Codable, CaseIterable {
case breakfast = "早餐"
case lunch = "午餐"
case dinner = "晚餐"
case snack = "點心"
}
接著我們調整Repository,為了取得SwiftData我們必須把ModelContext注入進來。並且因為是IO行為所以使用async/await來處理
接著我們要改寫MealRepository
,讓它能透過SwiftData操作資料。由於存取資料屬於 I/O 行為,因此使用async/await
進行非同步處理。
import SwiftData
import Foundation
class MealRepository: ObservableObject {
static let shared = MealRepository()
@Published private(set) var records: [MealRecord] = []
private var modelContext: ModelContext?
func configure(context: ModelContext) async{
self.modelContext = context
await fetchAll()
}
func addMeal(name: String, calories: Int, category: MealCategory) async{
guard let context = modelContext else { return }
let newMeal = MealRecord(
name: name,
calories: calories,
category: category,
date: .now
)
context.insert(newMeal)
do {
try context.save()
await fetchAll() // 重新抓取資料
} catch {
print("無法儲存資料:\(error)")
}
}
func fetchAll() async{
guard let context = modelContext else { return }
let descriptor = FetchDescriptor<MealRecord>(
sortBy: [SortDescriptor(\.date, order: .reverse)]
)
do {
records = try context.fetch(descriptor)
} catch {
print("無法讀取資料:\(error)")
}
}
func delete(_ record: MealRecord) async{
guard let context = modelContext else { return }
context.delete(record)
do {
try context.save()
await fetchAll()
} catch {
print("無法刪除資料:\(error)")
}
}
}
確保整個 App 都有 SwiftData 環境。
在App主程式中加上.modelContainer(for:)。
import SwiftUI
@main
struct ITHelpSideProjectApp: App {
var body: some Scene {
WindowGroup {
DashboardView()
}
.modelContainer(for: MealRecord.self)
}
}
ViewModel 將會負責串接 Repository 並提供給 UI 使用。
import SwiftUI
import SwiftData
class MealListViewModel: ObservableObject {
@Published var records: [MealRecord] = []
private let repository = MealRepository.shared
init() {
// 監聽 repository 的變化
repository.$records
.receive(on: RunLoop.main)
.assign(to: &$records)
}
func configure(context: ModelContext) {
Task {
await repository.configure(context: context)
}
}
/// 重新從資料庫載入
func refresh() {
Task {
await repository.fetchAll()
}
}
/// 新增一筆餐點紀錄
func addMeal(name: String, calories: Int, category: MealCategory) {
Task {
await repository.addMeal(name: name, calories: calories, category: category)
}
}
/// 刪除一筆紀錄
func delete(at offsets: IndexSet) {
for index in offsets {
let record = records[index]
Task {
await repository.delete(record)
}
}
}
}
import SwiftUI
import SwiftData
struct MealListView: View {
@Environment(\.modelContext) private var modelContext
@StateObject private var viewModel = MealListViewModel()
var body: some View {
VStack() {
if (viewModel.records.isEmpty) {
Text("尚未新增餐點")
.foregroundColor(.gray)
} else {
List {
ForEach(viewModel.records) { meal in
VStack(alignment: .leading, spacing: 6) {
Text(meal.name)
.font(.headline)
Text("\(meal.calories) 大卡")
.foregroundColor(.gray)
Text(meal.category.rawValue)
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
.onDelete {indexSet in
viewModel.delete(at:indexSet)
}
}
}
}.navigationTitle("飲食紀錄")
.toolbar {
NavigationLink(destination: AddMealView()) {
Image(systemName: "plus")
}
}
.onAppear {
viewModel.configure(context: modelContext)
}
}
}
#Preview {
MealListView()
}
Section {
NavigationLink(destination: MealListView()) {
Label("查看所有飲食紀錄", systemImage: "list.bullet.rectangle")
.font(.headline)
.foregroundColor(.orange)
}
.frame(maxWidth: .infinity, alignment: .center)
}
完成後的清單頁能夠:
今天我們完成了飲食記錄 App 的清單功能